Skip to content

fix(stream) handle task switching #3609

Merged
TheodoreSpeaks merged 3 commits intofix/mothership-3from
fix/task-streams
Mar 16, 2026
Merged

fix(stream) handle task switching #3609
TheodoreSpeaks merged 3 commits intofix/mothership-3from
fix/task-streams

Conversation

@TheodoreSpeaks
Copy link
Collaborator

@TheodoreSpeaks TheodoreSpeaks commented Mar 16, 2026

Summary

Had issues with task streaming.

WIP

  1. Switching back wouldn't stream tasks
  2. Tasks updating deployments wouldn't update the deployed status in the full workflow view.

Switched to processing all task sse streams even in the background and added handling for deployment tool calls.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation
  • Other: ___________

Testing

Validated that switching between tasks doesn't cause issues and deployment tools successfully update the workflow status in the workflow view.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

Screenshots/Videos

@cursor
Copy link

cursor bot commented Mar 16, 2026

You have used all Bugbot PR reviews included in your free trial for your GitHub account on this workspace.

To continue using Bugbot reviews, enable Bugbot for your team in the Cursor dashboard.

@vercel
Copy link

vercel bot commented Mar 16, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Mar 16, 2026 8:22pm

Request Review

@TheodoreSpeaks
Copy link
Collaborator Author

@cursor review

@cursor
Copy link

cursor bot commented Mar 16, 2026

You have used all Bugbot PR reviews included in your free trial for your GitHub account on this workspace.

To continue using Bugbot reviews, enable Bugbot for your team in the Cursor dashboard.

@TheodoreSpeaks TheodoreSpeaks merged commit 7316482 into fix/mothership-3 Mar 16, 2026
3 checks passed
@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 16, 2026

Greptile Summary

This PR fixes task-switching behaviour in the streaming chat system: when a user navigates back to a task that has an ongoing stream, the hook now detects the presence of an activeStreamId without a cached snapshot and retries fetching the task detail rather than immediately attempting a reconnect. It also adds client-side deployment-registry updates inside use-chat.ts (mirroring what handlers.ts does for live streams) so that deployment state is correctly hydrated when reconnecting to a historical stream.

Key changes:

  • use-chat.ts: Early-return + invalidateQueries when switching to a task with an active stream but no snapshot yet; removes the abort-controller teardown on unmount so the underlying connection can be preserved across switches; adds deployment status invalidation after deploy_api / deploy_chat / redeploy tool results.
  • handlers.ts: Deployment-status update now requires an explicit typeof isDeployed === 'boolean' guard and reads deployedAt from the server payload, fixing a pre-existing false-positive for deploy_mcp.
  • deploy.ts: Backend deploy/redeploy outputs now include workflowId and isDeployed consistently; deploy_chat undeploy path intentionally omits isDeployed (chat undeploy does not affect API deployment status) but is undocumented.

Issues found:

  • The early-return logic in use-chat.ts never sets appliedChatIdRef.current, so if the server keeps returning activeStreamId with no snapshot, invalidateQueries is called on every query resolution with no throttling — creating an unbounded polling loop that bypasses the query's 30-second staleTime.
  • Removing abortControllerRef.current?.abort() from the unmount cleanup means the ReadableStreamDefaultReader loop inside processSSEStream continues executing after the component unmounts, calling setMessages and other state setters on stale React state.
  • The PR description is explicitly marked WIP and all checklist items are unchecked, suggesting the change may not be complete or fully tested.

Confidence Score: 2/5

  • Not ready to merge — the unbounded polling loop and post-unmount state updates are active regressions that should be resolved before shipping.
  • The PR is self-described as WIP and introduces two logic issues: (1) a polling loop with no back-off that hammers the chat history endpoint whenever a task is switched to before its snapshot is available, and (2) removal of the abort-controller cleanup that allows SSE reader callbacks to fire on unmounted components. The deployment output changes in deploy.ts are correct and safe, but the hook changes carry meaningful risk.
  • apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts requires the most attention — the early-return polling pattern and removed abort cleanup both need to be revisited before merge.

Important Files Changed

Filename Overview
apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts Core streaming hook for task chat; two logic issues found: (1) early-return without setting appliedChatIdRef creates an unbounded polling loop when switching to a task with an active stream but no snapshot, and (2) removing the abort-controller cleanup lets the SSE reader continue calling state setters after unmount.
apps/sim/lib/copilot/client-sse/handlers.ts Deploy-tool result handling tightened: now requires typeof isDeployed === 'boolean' before updating the registry and reads deployedAt from the server payload instead of always using new Date(). The change is correct and eliminates a pre-existing false-positive update for deploy_mcp.
apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/deploy.ts Backend deploy functions updated to include workflowId and isDeployed in outputs for proper client-side registry hydration; deploy_chat undeploy path intentionally omits isDeployed (chat undeploy doesn't affect the API deployment) but this asymmetry is undocumented and could confuse future maintainers.

Sequence Diagram

sequenceDiagram
    participant User
    participant useChat
    participant QueryClient
    participant Server

    Note over User,Server: Task switching — happy path (snapshot available)
    User->>useChat: Switch to Task B (initialChatId changes)
    useChat->>useChat: Reset state, appliedChatIdRef = undefined
    useChat->>QueryClient: useChatHistory(taskBId)
    QueryClient->>Server: GET /api/copilot/chat?chatId=taskB
    Server-->>QueryClient: { activeStreamId, streamSnapshot: {...} }
    QueryClient-->>useChat: chatHistory updated
    useChat->>useChat: snapshot present → skip early return
    useChat->>useChat: appliedChatIdRef = chatHistory.id
    useChat->>useChat: setMessages, reconnect SSE

    Note over User,Server: Task switching — no snapshot yet (polling loop risk)
    User->>useChat: Switch to Task C (stream just started)
    useChat->>QueryClient: useChatHistory(taskCId)
    QueryClient->>Server: GET /api/copilot/chat?chatId=taskC
    Server-->>QueryClient: { activeStreamId, streamSnapshot: null }
    QueryClient-->>useChat: chatHistory updated
    useChat->>useChat: activeStreamId && !snapshot → invalidateQueries (NO appliedChatIdRef set)
    useChat->>QueryClient: invalidateQueries(taskKeys.detail(taskCId))
    QueryClient->>Server: GET /api/copilot/chat?chatId=taskC (refetch)
    Server-->>QueryClient: { activeStreamId, streamSnapshot: null } (still no snapshot)
    Note over useChat,Server: Loop repeats until snapshot appears or stream ends

    Note over User,Server: Deploy tool result — client-side registry update
    useChat->>useChat: tool_result for deploy_api / deploy_chat / redeploy
    useChat->>useChat: Check output.isDeployed is boolean
    useChat->>useChat: setDeploymentStatus in workflowRegistry
    useChat->>QueryClient: invalidate deploymentKeys.info + versions + workflowKeys.list
Loading

Last reviewed commit: cc1ae03

Comment on lines 1150 to 1155
useEffect(() => {
return () => {
streamGenRef.current++
// Only drop the browser→Sim read; the Sim→Go stream stays open
// so the backend can finish persisting. Explicit abort is only
// triggered by the stop button via /api/copilot/chat/abort.
abortControllerRef.current?.abort()
abortControllerRef.current = null
sendingRef.current = false
}
}, [])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed abort means stream continues processing after unmount

The previous cleanup explicitly called abortControllerRef.current?.abort() to cancel the in-flight SSE fetch when the component unmounts. That line was intentionally removed here to allow the backend stream to stay alive across task switches, but it has a side effect: the ReadableStreamDefaultReader inside processSSEStream's while (true) { reader.read() } loop keeps running after unmount.

streamGenRef.current++ prevents finalizeRef.current() from being called with a stale generation, but the inner flush() calls — which call setMessages, setIsSending, setIsReconnecting etc. — are not gated by the generation counter. If the stream is still in flight when the user switches tasks, state setters are called on the now-stale React fiber, which can cause React warnings and ghost state.

Consider aborting the reader without aborting the server-side stream — i.e., abort the client fetch connection while keeping the Go-side stream alive. The server-side stream can remain open without the client holding a reader:

Comment on lines 88 to +93
}
await db.delete(chat).where(eq(chat.id, existing[0].id))
return { success: true, output: { success: true, action: 'undeploy', isDeployed: false } }
return {
success: true,
output: { workflowId, success: true, action: 'undeploy', isChatDeployed: false },
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deploy_chat undeploy response is missing isDeployed for symmetry

The deploy path returns isDeployed: true, but the undeploy path now returns isChatDeployed: false with no isDeployed field. The client-side handlers in both handlers.ts and use-chat.ts gate their registry update on typeof output?.isDeployed === 'boolean', so they silently skip the undeploy case.

This is arguably correct behaviour (undeploying a chat does not remove the workflow's API deployment), but the asymmetry between the deploy and undeploy output shapes makes intent harder to follow. A comment explaining why isDeployed is intentionally absent from the undeploy response would help future maintainers:

Suggested change
}
await db.delete(chat).where(eq(chat.id, existing[0].id))
return { success: true, output: { success: true, action: 'undeploy', isDeployed: false } }
return {
success: true,
output: { workflowId, success: true, action: 'undeploy', isChatDeployed: false },
}
return {
success: true,
// isDeployed is intentionally omitted: undeploying a chat does not
// affect the workflow's API deployment status.
output: { workflowId, success: true, action: 'undeploy', isChatDeployed: false },
}

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +372 to +375
if (activeStreamId && !snapshot && !sendingRef.current) {
queryClient.invalidateQueries({ queryKey: taskKeys.detail(chatHistory.id) })
return
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unbounded polling loop when no snapshot is available

When switching to a task that has an active stream but no snapshot, this early return path intentionally skips setting appliedChatIdRef.current = chatHistory.id. That means every time the invalidated query resolves and chatHistory updates, this useEffect fires again, evaluates the same condition, and calls invalidateQueries once more — creating a tight polling loop for the lifetime of the stream.

useChatHistory has a staleTime of 30 seconds, but invalidateQueries bypasses staleTime and triggers an immediate refetch. So the loop rate is limited only by network round-trip time, which could produce dozens of requests per minute if the server takes a while to produce a snapshot.

If the intent is to poll until a snapshot appears, this needs an explicit back-off or maximum retry count. One approach is to return a cleanup function from the effect that uses a delayed invalidateQueries call so subsequent polls are spaced out, rather than firing immediately on every data update.

TheodoreSpeaks added a commit that referenced this pull request Mar 16, 2026
* Fix deploy subagent

* fix(stream) handle task switching   (#3609)

* Fix task switching causing stream to abort

* Process all task streams all the time

* Process task streams that are in the background

---------

Co-authored-by: Theodore Li <theo@sim.ai>

* Always return isDeployed for undeploy chat

* Fix lint

---------

Co-authored-by: Siddharth Ganesan <siddharthganesan@gmail.com>
Co-authored-by: Theodore Li <theo@sim.ai>
emir-karabeg pushed a commit that referenced this pull request Mar 17, 2026
* Fix deploy subagent

* fix(stream) handle task switching   (#3609)

* Fix task switching causing stream to abort

* Process all task streams all the time

* Process task streams that are in the background

---------

Co-authored-by: Theodore Li <theo@sim.ai>

* Always return isDeployed for undeploy chat

* Fix lint

---------

Co-authored-by: Siddharth Ganesan <siddharthganesan@gmail.com>
Co-authored-by: Theodore Li <theo@sim.ai>
@waleedlatif1 waleedlatif1 deleted the fix/task-streams branch March 17, 2026 17:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant